feat: Render Markdown in chat and query output using rich#22
Conversation
Use rich's Live + Markdown to progressively render LLM responses in the chat REPL with proper formatting (headings, bold, code blocks, lists, etc.) instead of raw text output. Falls back to plain text when --no-color is set. Tool call lines still use prompt_toolkit styled output.
Apply the same rich Live + Markdown streaming render to `openkb query` as was added to chat. Falls back to plain text when stdout is not a tty.
f206c75 to
5d2b4b4
Compare
Code reviewFound 1 issue:
Lines 153 to 160 in 5d2b4b4 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
- Create a fresh Live instance after each tool call instead of
stop/start on the same instance, preventing the auto-refresh
thread from overwriting stdout text with CURSOR_UP sequences
- Respect NO_COLOR env var in query.py (consistent with chat.py)
- Fix print("\n") → print() in chat.py finally block to avoid
extra blank line when Live is active
Custom Rich Theme: bold headings without color, dark background for inline code, subtle link/list/blockquote colors. Shared via _make_rich_console() used by both chat and query.
- Headings: blue (#5fa0e0) - Code: yellow-ish on dark background - List bullets: green (#6ac0a0) - Bold: bright white, italic: light gray - Paragraph text: light gray for visibility
Code reviewFound 1 issue:
Lines 276 to 303 in 8cdc5a7
Lines 150 to 168 in 8cdc5a7 Fix: use a separate display buffer that resets when Live is restarted, while keeping 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Two issues in the Live-based markdown streaming: 1. After a tool call, the new Live instance re-rendered the entire response so far because `collected` was never reset. Text before and after the tool call would be rendered together as one markdown block in the new Live region, with no separator between the two parts. Track the current segment separately from the full answer. 2. The tool_call_item handler started a new Live immediately after printing the tool line, which then had to be stopped in the text handler before the blank line. The empty Live start/stop plus the explicit `print()` produced two blank lines. Drop the premature start and let the next text delta create the new Live.
The previous approach used rich.markdown.Markdown with a hand-written Theme. Two problems: - Rich centers h1/h2 headings in a Panel, which looks out of place in chat output. - The Theme pinned a gray paragraph color (`#d0d0d0`) that overrode the terminal's default foreground for every line of assistant text. Replace with a renderer (openkb/agent/_markdown.py) that parses with markdown-it-py and maps each token to Rich primitives directly: Text for inline content, Syntax for code blocks, Group for block stacking. Plain text, bold, italic, and strikethrough keep the terminal's default color and only carry style attributes. Headings are left- aligned (bold, with h1 also italic + underline), list nesting uses decimal / letters / roman numerals by depth, blockquotes prefix non-blank lines with a dim vertical bar, tables render as pipes and dashes, and links emit OSC 8 hyperlinks where applicable. _make_rich_console loses the Theme (no reason to carry one now) and _make_markdown wraps the new renderer. query.py imports it from chat.py instead of constructing Markdown itself.
Code reviewFound 1 issue:
Lines 162 to 170 in dab7379 Compare with the lazy pattern in chat.py: Lines 248 to 284 in dab7379 Prior review comments
🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
- Drop eager Live restart after a tool call, mirroring the lazy pattern used in chat.py (commit 8708fc4 applied the fix only to chat.py) - Start Live inside the try block so a synchronous raise cannot leak it
- Preserve list item indent on multi-line paragraphs and block children - Render fenced/code blocks inside lists and blockquotes as plain text instead of leaking <rich.syntax.Syntax object at ...> repr - Preserve mailto link text when it differs from the email address
61c49e6 to
a9823fd
Compare
Code reviewFound 1 issue:
OpenKB/openkb/agent/_markdown.py Lines 54 to 58 in a9823fd Prior review comments
🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. Prior review comments
🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
- Hoist MarkdownIt to a module-level singleton instead of rebuilding it on every call to render() - Re-render the Live region only when a text delta contains a newline, using the content up to the last newline as the visible snapshot. Flush the full segment once before stopping Live around tool calls and in the finally block so the trailing partial line is not lost
Replace `[tool call] name(args)` written via bare sys.stdout.write with chat's `_format_tool_line` (truncated to 78 chars, marker `·`) printed through `_fmt` with the `class:tool` style, so the tool-call line inherits the same color as in chat. Build the prompt-toolkit Style from chat's `_build_style` based on use_color.
When set, skips Rich Live + markdown rendering and writes the model's markdown source directly to stdout. Other styling is preserved: the chat prompt remains colored and the tool-call line keeps its `class:tool` style. This is distinct from --no-color / NO_COLOR, which strips all coloring.
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance (no CLAUDE.md files present in the repo). Prior review comments on this PR all appear addressed by follow-up commits (5d2b4b4, 8708fc4, 80e7e7a, 88b61fc). - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Avoid shadowing the new raw: bool parameter, matching query.py.
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. Prior review comments on this PR appear resolved in the current head (497ec2f). 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Adopt the same last_was_text / need_blank_before_text state machine used in chat.py so tool-call separator blank lines are written lazily. This removes the extra blank line above tool lines and avoids a stray blank between consecutive tool calls or at the end of a turn.
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. Also verified that prior review comments (Live lifecycle reuse, fence 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |

Summary
rich.live.Live+rich.markdown.Markdownto progressively render LLM responses with proper terminal formatting (headings, bold, code blocks, lists, etc.)openkb chat(interactive REPL) andopenkb query(single-shot)--no-coloris set (chat) or stdout is not a tty (query)rich>=13.0as an explicit dependencyTest plan
python -m pytest tests/— 197 passedopenkb chat— verify headings, bold, code blocks render with formattingopenkb chat --no-color— verify plain text fallbackopenkb query "question"— verify formatted outputopenkb query "question" > file.txt— verify no ANSI codes in file